/**
* Copyright (C) 2014 cherimojava (http://github.com/cherimojava/orchidae) Licensed under the Apache License, Version
* 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the
* License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the specific language governing permissions and limitations
* under the License.
*/
package com.github.cherimojava.orchidae.controller;
import static com.github.cherimojava.orchidae.util.FileUtil.generateId;
import static java.lang.String.format;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.Iterator;
import java.util.List;
import javax.imageio.ImageIO;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.imgscalr.Scalr;
import org.joda.time.DateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import com.github.cherimojava.data.mongo.entity.EntityFactory;
import com.github.cherimojava.data.mongo.query.OngoingQuery;
import com.github.cherimojava.data.mongo.query.QuerySort;
import com.github.cherimojava.data.mongo.query.QueryStart;
import com.github.cherimojava.orchidae.api.entities.Access;
import com.github.cherimojava.orchidae.api.entities.BatchUpload;
import com.github.cherimojava.orchidae.api.entities.Picture;
import com.github.cherimojava.orchidae.api.entities.User;
import com.github.cherimojava.orchidae.api.hook.UploadHook;
import com.github.cherimojava.orchidae.controller.api.UploadResponse;
import com.github.cherimojava.orchidae.hook.HookHandler;
import com.github.cherimojava.orchidae.util.FileUtil;
import com.github.cherimojava.orchidae.util.UserUtil;
import com.google.common.base.Joiner;
import com.google.common.collect.Lists;
import com.mongodb.client.MongoCursor;
/**
* Does the handling of uploading and serving pictures
*
* @author philnate
*/
@RestController
@RequestMapping( value = "/picture" )
public class PictureController
{
/**
* pattern for picture id
*/
public static final String PICTURE_ID = "/{id:[a-f0-9]+}";
/**
* URI pattern for pictures
*/
public static final String PICTURE_URI = "/{user}" + PICTURE_ID;
@Value( "${limit.latestPictures:30}" )
int latestPictureLimit;
@Value( "${picture.small.maxHeight:300}" )
int maxHeight;
@Autowired
EntityFactory factory;
@Autowired
FileUtil fileUtil;
@Autowired
UserUtil userUtil;
@Autowired
protected HookHandler hookHandler;
/**
* identifier on clientside for batch
*/
protected static final String BATCH_IDENTIFIER = "batch";
private static final Logger LOG = LogManager.getLogger();
/**
* Returns a list (json) with the {number} most recent photos of the given {user}.
*
* @param user to retrieve pictures from
* @param number (optional) number of pictures to ask for. Number is constrained by {@link #latestPictureLimit}
* @param skip number of pictures to skip. Must be non negativ
* @return picture json list with the latest pictures
* @since 1.0.0
*/
@RequestMapping( value = "/{user}/_latest", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE )
@PostFilter( "@paa.hasAccess(filterObject.id)" )
public List<Picture> latestPicturesMetaByUserLimit( @PathVariable( "user" ) String user,
@RequestParam( value = "n", required = false ) Integer number,
@RequestParam( value = "s", required = false ) Integer skip)
{
if ( number == null || number > latestPictureLimit )
{
LOG.info( "latest picture request was ({}) greater than max allowed {}. Only returning max", number,
latestPictureLimit );
number = latestPictureLimit;
}
QueryStart<Picture> query = factory.query( Picture.class );
QuerySort<Picture> querySort = query//
.where( query.e().getUser().getUsername() ).is( user )//
.and( query.e().isDeleted() ).is( false )//
.limit( number ).sort().desc( query.e().getOrder() );
if ( skip != null && skip > 0 )
{
querySort.skip( skip );
}
return Lists.newArrayList( querySort.iterator() );
}
@ResponseBody
@RequestMapping( value = PICTURE_URI
+ "/_next", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE )
@PreAuthorize( "@paa.hasAccess(#id)" )
@PostAuthorize( "@paa.hasAccess(returnObject)" )
public ResponseEntity<Picture> getNext( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id)
{
return getAdjacentPicture( user, id, false );
}
@ResponseBody
@RequestMapping( value = PICTURE_URI
+ "/_previous", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE )
@PreAuthorize( "@paa.hasAccess(#id)" )
@PostAuthorize( "@paa.hasAccess(returnObject)" )
public ResponseEntity<Picture> getPrevious( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id)
{
return getAdjacentPicture( user, id, true );
}
/**
* retrieves the picture adjacent to the given order number.
*
* @param user for which the lookup is performed
* @param id the order number to find its ancestor or successor
* @param previous should the ancestor or successor be found
* @return null if no adjacent picture was found for the given order number or the picture found
*/
protected ResponseEntity<Picture> getAdjacentPicture( String user, String id, boolean previous )
{
Picture pic = factory.load( Picture.class, id );
if ( pic != null )
{
QueryStart<Picture> query = factory.query( Picture.class );
OngoingQuery<Picture> oquery = query.where( query.e().getUser().getUsername() ).is( user );//
oquery.and( query.e().isDeleted() ).is( false );
if ( previous )
{
oquery.and( query.e().getOrder() ).lessThan( pic.getOrder() );
}
else
{
oquery.and( query.e().getOrder() ).greaterThan( pic.getOrder() );
}
MongoCursor<Picture> it = oquery.sort()
.by( previous ? QuerySort.Sort.DESC : QuerySort.Sort.ASC, query.e().getOrder() ).limit( 1 ).iterator();
if ( it.hasNext() )
{
return new ResponseEntity<>( it.next(), HttpStatus.OK );
}
}
return new ResponseEntity<>( HttpStatus.NOT_FOUND );
}
/**
* returns the total number of pictures for this user according to the permissions of the requester. E.g. the owner
* will get private pictures included, while everyone else doesn't
*
* @param user to retrieve the count of pictures from
* @return
* @since 1.0.0
*/
@RequestMapping( value = "/{user}/_count", method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE )
public ResponseEntity<String> latestPicturesMetaByUserLimit( @PathVariable( "user" ) String user)
{
QueryStart<Picture> query = factory.query( Picture.class );
OngoingQuery<Picture> oquery = query.where( query.e().getUser().getUsername() ).is( user );
oquery.and( query.e().isDeleted() ).is( false );
String curUser = UserUtil.getLoggedInUser();
if ( !StringUtils.equals( curUser, user ) )
{
oquery.and( query.e().getAccess() ).is( Access.PUBLIC );
}
return new ResponseEntity<>( format( "{\"count\":%d}", oquery.count() ),
HttpStatus.OK );
}
/**
* serves the requested picture {id} and size {f} for the given {user}
*
* @param user user to lookup picture
* @param id id of the picture to load
* @param format the format/Size of the picture to return
* @return the picture which belongs to the given id, or {@link org.springframework.http.HttpStatus#NOT_FOUND} if no
* such picture exists
* @throws IOException
* @since 1.0.0
* @see {@link #_getPicture(String, String)}
*/
@ResponseBody
@RequestMapping( value = PICTURE_URI, method = RequestMethod.GET, produces = MediaType.IMAGE_JPEG_VALUE, params = "f" )
@PreAuthorize( "@paa.hasAccess(#id)" )
public ResponseEntity<Resource> getPicture( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id,
@RequestParam( value = "f" ) String format)
throws IOException
{
ResponseEntity resp;
switch ( format )
{
case "s":// small image
return _getPicture( id, "_s" );
case "o":// Original
return _getPicture( id, "" );
default:// All unknown garbage
return new ResponseEntity<>( HttpStatus.NOT_FOUND );
}
}
/**
* serves the requested picture {id} metadata for the given {user}
*
* @param user user to lookup picture
* @param id picture id to lookup
* @return picture metadata
*/
@ResponseBody
@RequestMapping( value = PICTURE_URI, method = RequestMethod.GET, produces = MediaType.APPLICATION_JSON_VALUE, params = "!f" )
@PreAuthorize( "@paa.hasAccess(#id)" )
public ResponseEntity<Picture> getPictureMeta( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id)
{
Picture pic = factory.load( Picture.class, id );
return ( pic != null && !pic.isDeleted() ) ? new ResponseEntity<>( pic, HttpStatus.OK )
: new ResponseEntity<>( HttpStatus.NOT_FOUND );
}
/**
* deletes the given picture {id} and all related data for the given {user}
*
* @param user user to lookup picture
* @param id picture to delete
* @return appropriate HTTP code
*/
@ResponseBody
@RequestMapping( value = PICTURE_URI, method = RequestMethod.DELETE, produces = MediaType.APPLICATION_JSON_VALUE )
@PreAuthorize( "@paa.canDelete(#id)" )
public ResponseEntity<String> deletePicture( @PathVariable( "user" ) String user, @PathVariable( "id" ) String id)
{
Picture pic = factory.load( Picture.class, id );
if ( pic != null && !pic.isDeleted() )
{
// the actual cleanup should be triggered through some cronjob or manually
// File f = fileUtil.getFileHandle(pic.getId());
// f.delete();
// new File(f.getAbsolutePath() + "_s").delete();
pic.setDeleted( true ).save();
}
else
{
return new ResponseEntity<>( HttpStatus.NOT_FOUND );
}
return new ResponseEntity<>( HttpStatus.OK );
}
/**
* actual method retrieving the picture from disk. Requested picture is only returned if the current user is allowed
* to view it
*
* @param id identification of picture
* @param type type of the picture eg _t for thumbnail etc.
* @return ResponseEntity containing the resource to the picture or NOT_FOUND
* @throws IOException
*/
private ResponseEntity<Resource> _getPicture( String id, String type )
throws IOException
{
File picture = fileUtil.getFileHandle( id + type );
if ( picture.exists() )
{
return new ResponseEntity<>( new InputStreamResource( FileUtils.openInputStream( picture ) ),
HttpStatus.OK );
}
else
{
LOG.debug( "Could not find picture with id {}", id );
// picture doesn't exist so return 404
return new ResponseEntity<>( HttpStatus.NOT_FOUND );
}
}
/**
* uploads multiple files into the system for the current user
*
* @param request request with pictures to store
* @return {@link org.springframework.http.HttpStatus#CREATED} if the upload was successful or
* {@link org.springframework.http.HttpStatus#OK} if some of the pictures couldn't be uploaded together with
* information which pictures couldn't be uploaded
* @since 1.0.0
*/
@RequestMapping( method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.MULTIPART_FORM_DATA_VALUE )
public ResponseEntity<UploadResponse> upload( MultipartHttpServletRequest request,
@RequestParam( value = "batch", required = false ) String batchId)
{
List<String> badFiles = Lists.newArrayList();
UploadResponse response = factory.create( UploadResponse.class );
User user = userUtil.getUser( (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal() );
for ( Iterator<String> it = request.getFileNames(); it.hasNext(); )
{
MultipartFile file = request.getFile( it.next() );
String type = StringUtils.substringAfterLast( file.getOriginalFilename(), "." );
try
{
// Create uuid and Picture entity
Picture picture = EntityFactory.instantiate( Picture.class );
picture.setId( generateId() );
// save picture
File storedPicture = fileUtil.getFileHandle( picture.getId() );
file.transferTo( storedPicture );
BufferedImage image = ImageIO.read( FileUtils.openInputStream( storedPicture ) );
// Call all hooks
UploadHook.UploadInfo ui = new UploadHook.UploadInfo();
ui.pictureUploaded = picture;
ui.uploadedFile = file;
ui.uploadingUser = user;
ui.storedImage = image;
hookHandler.callHook( UploadHook.class ).callAll().upload( ui );
// todo, would be good if this could be moved into hook as well
createSmall( picture.getId(), image, type );
checkBatch( picture, batchId );
// save picture
factory.save( picture );
LOG.info( "Uploaded {} and assigned id {}", file.getOriginalFilename(), picture.getId() );
// after the picture is saved we can add it to the response
response.addIds( picture.getId() );
}
catch ( Exception e )
{
LOG.warn( "failed to store picture", e );
badFiles.add( file.getOriginalFilename() );
}
}
user.save();// We should persist this information? Or should we rely on the persistence magic?
if ( badFiles.isEmpty() )
{
return new ResponseEntity<>( response, HttpStatus.CREATED );
}
else
{
response.setIds( Lists.<String> newArrayList() );
return new ResponseEntity<>(
response.setMsg( "Could not upload all files. Failed to upload: " + Joiner.on( "," ).join( badFiles ) ),
HttpStatus.OK );
}
}
/**
* check if batching should be applied to the current picture upload
*
* @param pic
* @param batchId
*/
private void checkBatch( Picture pic, String batchId )
{
if ( StringUtils.isNotEmpty( batchId ) )
{
if ( !FileUtil.validateId( batchId ) )
{
// ignore the batching if the id isn't valid
return;
}
BatchUpload batch = factory.load( BatchUpload.class, batchId );
// if the batch doesn't exist, create it
if ( batch == null )
{
batch = factory.create( BatchUpload.class );
batch.setUploadDate( DateTime.now() ).setId( batchId );
}
batch.addPictures( pic );
pic.setBatchUpload( batch );
batch.save();
}
}
/**
* creates the Thumbnail for the given picture and stores it on the disk
*
* @param id
* @param image
* @param type
*/
private void createSmall( String id, BufferedImage image, String type )
{
int height = image.getHeight();
int width = image.getWidth();
double scale = maxHeight / (double) height;
BufferedImage thumbnail = Scalr.resize( image, Scalr.Method.ULTRA_QUALITY,
( (Double) ( width * scale ) ).intValue(), ( (Double) ( height * scale ) ).intValue(), Scalr.OP_ANTIALIAS );
try
{
ImageIO.write( thumbnail, type, fileUtil.getFileHandle( id + "_s" ) );
}
catch ( IOException e )
{
LOG.error( "failed to create thumbnail for picture", e );
}
}
}